#!/usr/bin/env python3
""" Extract Box Editor for the manual adjustments tool """

import platform

import numpy as np

from lib.gui.custom_widgets import RightClickMenu
from lib.gui.utils import get_config
from ._base import Editor, logger


class ExtractBox(Editor):
    """ The Extract Box Editor.

    Adjust the calculated Extract Box to shift all of the 68 point landmarks in place.

    Parameters
    ----------
    canvas: :class:`tkinter.Canvas`
        The canvas that holds the image and annotations
    detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
        The _detected_faces data for this manual session
    """
    def __init__(self, canvas, detected_faces):
        self._right_click_menu = RightClickMenu(["Delete Face"],
                                                [self._delete_current_face],
                                                ["Del"])
        control_text = ("Extract Box Editor\nMove the extract box that has been generated by the "
                        "aligner. Click and drag:\n\n"
                        " - Inside the bounding box to relocate the landmarks.\n"
                        " - The corner anchors to resize the landmarks.\n"
                        " - Outside of the corners to rotate the landmarks.")
        key_bindings = {"<Delete>": self._delete_current_face}
        super().__init__(canvas, detected_faces,
                         control_text=control_text, key_bindings=key_bindings)

    @property
    def _corner_order(self):
        """ dict: The position index of bounding box corners """
        return {0: ("top", "left"),
                3: ("top", "right"),
                2: ("bottom", "right"),
                1: ("bottom", "left")}

    def update_annotation(self):
        """ Draw the latest Extract Boxes around the faces. """
        color = self._control_color
        roi = self._zoomed_roi
        for idx, face in enumerate(self._face_iterator):
            logger.trace("Drawing Extract Box: (idx: %s, roi: %s)", idx, face.original_roi)
            if self._globals.is_zoomed:
                box = np.array((roi[0], roi[1], roi[2], roi[1], roi[2], roi[3], roi[0], roi[3]))
            else:
                face.load_aligned(None, force=True)
                box = self._scale_to_display(face.original_roi).flatten()
            top_left = box[:2] - 10
            kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(idx))
            self._object_tracker("eb_text", "text", idx, top_left, kwargs)
            kwargs = dict(fill="", outline=color, width=1)
            self._object_tracker("eb_box", "polygon", idx, box, kwargs)
            self._update_anchor_annotation(idx, box, color)
        logger.trace("Updated extract box annotations")

    def _update_anchor_annotation(self, face_index, extract_box, color):
        """ Update the anchor annotations for each corner of the extract box.

        The anchors only display when the extract box editor is active.

        Parameters
        ----------
        face_index: int
            The index of the face being annotated
        extract_box: :class:`numpy.ndarray`
            The scaled extract box to get the corner anchors for
        color: str
            The hex color of the extract box line
        """
        if not self._is_active or self._globals.is_zoomed:
            self.hide_annotation("eb_anc_dsp")
            self.hide_annotation("eb_anc_grb")
            return
        fill_color = "gray"
        activefill_color = "white" if self._is_active else ""
        anchor_points = self._get_anchor_points((extract_box[:2],
                                                 extract_box[2:4],
                                                 extract_box[4:6],
                                                 extract_box[6:]))
        for idx, (anc_dsp, anc_grb) in enumerate(zip(*anchor_points)):
            dsp_kwargs = dict(outline=color, fill=fill_color, width=1)
            grb_kwargs = dict(outline="", fill="", width=1, activefill=activefill_color)
            dsp_key = "eb_anc_dsp_{}".format(idx)
            grb_key = "eb_anc_grb_{}".format(idx)
            self._object_tracker(dsp_key, "oval", face_index, anc_dsp, dsp_kwargs)
            self._object_tracker(grb_key, "oval", face_index, anc_grb, grb_kwargs)
        logger.trace("Updated extract box anchor annotations")

    # << MOUSE HANDLING >>
    # Mouse cursor display
    def _update_cursor(self, event):
        """ Update the cursor when it is hovering over an extract box and update
        :attr:`_mouse_location` with the current cursor position.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The current tkinter mouse event
        """
        if self._check_cursor_anchors():
            return
        if self._check_cursor_box():
            return
        if self._check_cursor_rotate(event):
            return
        self._canvas.config(cursor="")
        self._mouse_location = None

    def _check_cursor_anchors(self):
        """ Check whether the cursor is over a corner anchor.

        If it is, set the appropriate cursor type and set :attr:`_mouse_location` to
        ("anchor", `face index`, `corner_index`)

        Returns
        -------
        bool
            ``True`` if cursor is over an anchor point otherwise ``False``
        """
        anchors = set(self._canvas.find_withtag("eb_anc_grb"))
        item_ids = set(self._canvas.find_withtag("current")).intersection(anchors)
        if not item_ids:
            return False
        item_id = list(item_ids)[0]
        tags = self._canvas.gettags(item_id)
        face_idx = int(next(tag for tag in tags if tag.startswith("face_")).split("_")[-1])
        corner_idx = int(next(tag for tag in tags
                              if tag.startswith("eb_anc_grb_")
                              and "face_" not in tag).split("_")[-1])

        self._canvas.config(cursor="{}_{}_corner".format(*self._corner_order[corner_idx]))
        self._mouse_location = ("anchor", face_idx, corner_idx)
        return True

    def _check_cursor_box(self):
        """ Check whether the cursor is inside an extract box.

        If it is, set the appropriate cursor type and set :attr:`_mouse_location` to
        ("box", `face index`)

        Returns
        -------
        bool
            ``True`` if cursor is over a rotate point otherwise ``False``
        """
        extract_boxes = set(self._canvas.find_withtag("eb_box"))
        item_ids = set(self._canvas.find_withtag("current")).intersection(extract_boxes)
        if not item_ids:
            return False
        item_id = list(item_ids)[0]
        self._canvas.config(cursor="fleur")
        self._mouse_location = ("box", next(int(tag.split("_")[-1])
                                            for tag in self._canvas.gettags(item_id)
                                            if tag.startswith("face_")))
        return True

    def _check_cursor_rotate(self, event):
        """ Check whether the cursor is in an area to rotate the extract box.

        If it is, set the appropriate cursor type and set :attr:`_mouse_location` to
        ("rotate", `face index`)

        Notes
        -----
        This code is executed after the check has been completed to see if the mouse is inside
        the extract box. For this reason, we don't bother running a check to see if the mouse
        is inside the box, as this code will never run if that is the case.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The current tkinter mouse event

        Returns
        -------
        bool
            ``True`` if cursor is over a rotate point otherwise ``False``
        """
        distance = 30
        boxes = np.array([np.array(self._canvas.coords(item_id)).reshape(4, 2)
                          for item_id in self._canvas.find_withtag("eb_box")
                          if self._canvas.itemcget(item_id, "state") != "hidden"])
        position = np.array((event.x, event.y)).astype("float32")
        for face_idx, points in enumerate(boxes):
            if any(np.all(position > point - distance) and np.all(position < point + distance)
                   for point in points):
                self._canvas.config(cursor="exchange")
                self._mouse_location = ("rotate", face_idx)
                return True
        return False

    # Mouse click actions
    def set_mouse_click_actions(self):
        """ Add context menu to OS specific right click action. """
        super().set_mouse_click_actions()
        self._canvas.bind("<Button-2>" if platform.system() == "Darwin" else "<Button-3>",
                          self._context_menu)

    def _drag_start(self, event):
        """ The action to perform when the user starts clicking and dragging the mouse.

        Selects the correct extract box action based on the initial cursor position.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event.
        """
        if self._mouse_location is None:
            self._drag_data = dict()
            self._drag_callback = None
            return
        self._drag_data["current_location"] = np.array((event.x, event.y))
        callback = dict(anchor=self._resize, rotate=self._rotate, box=self._move)
        self._drag_callback = callback[self._mouse_location[0]]

    def _drag_stop(self, event):  # pylint: disable=unused-argument
        """ Trigger a viewport thumbnail update on click + drag release

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event. Required but unused.
        """
        if self._mouse_location is None:
            return
        self._det_faces.update.post_edit_trigger(self._globals.frame_index,
                                                 self._mouse_location[1])

    def _move(self, event):
        """ Updates the underlying detected faces landmarks based on mouse dragging delta,
        which moves the Extract box on a drag event.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event.
        """
        if not self._drag_data:
            return
        shift_x = event.x - self._drag_data["current_location"][0]
        shift_y = event.y - self._drag_data["current_location"][1]
        scaled_shift = self.scale_from_display(np.array((shift_x, shift_y)), do_offset=False)
        self._det_faces.update.landmarks(self._globals.frame_index,
                                         self._mouse_location[1],
                                         *scaled_shift)
        self._drag_data["current_location"] = (event.x, event.y)

    def _resize(self, event):
        """ Resizes the landmarks contained within an extract box on a corner anchor drag event.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event.
        """
        face_idx = self._mouse_location[1]
        face_tag = "eb_box_face_{}".format(face_idx)
        position = np.array((event.x, event.y))
        box = np.array(self._canvas.coords(face_tag))
        center = np.array((sum(box[0::2]) / 4, sum(box[1::2]) / 4))
        if not self._check_in_bounds(center, box, position):
            logger.trace("Drag out of bounds. Not updating")
            self._drag_data["current_location"] = position
            return

        start = self._drag_data["current_location"]
        distance = ((np.linalg.norm(center - start) - np.linalg.norm(center - position))
                    * get_config().scaling_factor)
        size = ((box[2] - box[0]) ** 2 + (box[3] - box[1]) ** 2) ** 0.5
        scale = 1 - (distance / size)
        logger.trace("face_index: %s, center: %s, start: %s, position: %s, distance: %s, "
                     "size: %s, scale: %s", face_idx, center, start, position, distance, size,
                     scale)
        if size * scale < 20:
            # Don't over shrink the box
            logger.trace("Box would size to less than 20px. Not updating")
            self._drag_data["current_location"] = position
            return

        self._det_faces.update.landmarks_scale(self._globals.frame_index,
                                               face_idx,
                                               scale,
                                               self.scale_from_display(center))
        self._drag_data["current_location"] = position

    def _check_in_bounds(self, center, box, position):
        """ Ensure that a resize drag does is not going to cross the center point from it's initial
        corner location.

        Parameters
        ----------
        center: :class:`numpy.ndarray`
            The (`x`, `y`) center point of the face extract box
        box: :class:`numpy.ndarray`
            The canvas coordinates of the extract box polygon's corners
        position: : class:`numpy.ndarray`
            The current (`x`, `y`) position of the mouse cursor

        Returns
        -------
        bool
            ``True`` if the drag operation does not cross the center point otherwise ``False``
        """
        # Generate lines that span the full frame (x and y) along the center point
        center_x = np.array(((center[0], 0), (center[0], self._globals.frame_display_dims[1])))
        center_y = np.array(((0, center[1]), (self._globals.frame_display_dims[0], center[1])))

        # Generate a line coming from the current corner location to the current cursor position
        full_line = np.array((box[self._mouse_location[2] * 2:self._mouse_location[2] * 2 + 2],
                              position))
        logger.trace("center: %s, center_x_line: %s, center_y_line: %s, full_line: %s",
                     center, center_x, center_y, full_line)

        # Check whether any of the generated lines intersect
        for line in (center_x, center_y):
            if (self._is_ccw(full_line[0], *line) != self._is_ccw(full_line[1], *line) and
                    self._is_ccw(*full_line, line[0]) != self._is_ccw(*full_line, line[1])):
                logger.trace("line: %s crosses center: %s", full_line, center)
                return False
        return True

    @staticmethod
    def _is_ccw(point_a, point_b, point_c):
        """ Check whether 3 points are counter clockwise from each other.

        Parameters
        ----------
        point_a: :class:`numpy.ndarray`
            The first (`x`, `y`) point to check for counter clockwise ordering
        point_b: :class:`numpy.ndarray`
            The second (`x`, `y`) point to check for counter clockwise ordering
        point_c: :class:`numpy.ndarray`
            The third (`x`, `y`) point to check for counter clockwise ordering

        Returns
        -------
        bool
            ``True`` if the 3 points are provided in counter clockwise order otherwise ``False``
        """
        return ((point_c[1] - point_a[1]) * (point_b[0] - point_a[0]) >
                (point_b[1] - point_a[1]) * (point_c[0] - point_a[0]))

    def _rotate(self, event):
        """ Rotates the landmarks contained within an extract box on a corner rotate drag event.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event.
        """
        face_idx = self._mouse_location[1]
        face_tag = "eb_box_face_{}".format(face_idx)
        box = np.array(self._canvas.coords(face_tag))
        position = np.array((event.x, event.y))

        center = np.array((sum(box[0::2]) / 4, sum(box[1::2]) / 4))
        init_to_center = self._drag_data["current_location"] - center
        new_to_center = position - center
        angle = np.rad2deg(np.arctan2(*new_to_center) - np.arctan2(*init_to_center))
        logger.trace("face_index: %s, box: %s, center: %s, init_to_center: %s, new_to_center: %s"
                     "center: %s, angle: %s", face_idx, box, center, init_to_center, new_to_center,
                     center, angle)

        self._det_faces.update.landmarks_rotate(self._globals.frame_index,
                                                face_idx,
                                                angle,
                                                self.scale_from_display(center))
        self._drag_data["current_location"] = position

    def _get_scale(self):
        """ Obtain the scaling for the extract box resize """

    def _context_menu(self, event):
        """ Create a right click context menu to delete the alignment that is being
        hovered over. """
        if self._mouse_location is None or self._mouse_location[0] != "box":
            return
        self._right_click_menu.popup(event)

    def _delete_current_face(self, *args):  # pylint:disable=unused-argument
        """ Called by the right click delete event. Deletes the face that the mouse is currently
        over.

        Parameters
        ----------
        args: tuple (unused)
            The event parameter is passed in by the hot key binding, so args is required
        """
        if self._mouse_location is None or self._mouse_location[0] != "box":
            return
        self._det_faces.update.delete(self._globals.frame_index, self._mouse_location[1])
